/**
 * Copyright (c) 2017-2023 Nop Platform. All rights reserved.
 * Author: canonical_entropy@163.com
 * Blog:   https://www.zhihu.com/people/canonical-entropy
 * Gitee:  https://gitee.com/canonical-entropy/nop-chaos
 * Github: https://github.com/entropy-cloud/nop-chaos
 */
package io.nop.job.core.calendar;

import io.nop.job.core.ICalendar;

import java.text.NumberFormat;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.StringTokenizer;
import java.util.TimeZone;

/**
 * This implementation of the Calendar excludes (or includes - see below) a specified time range each day. For example,
 * you could use this calendar to exclude business hours (8AM - 5PM) every day. Each <CODE>DailyCalendar</CODE> only
 * allows a single time range to be specified, and that time range may not cross daily boundaries (i.e. you cannot
 * specify a time range from 8PM - 5AM). If the property <CODE>invertTimeRange</CODE> is <CODE>false</CODE> (default),
 * the time range defines a range of times in which triggers are not allowed to fire. If <CODE>invertTimeRange</CODE> is
 * <CODE>true</CODE>, the time range is inverted &ndash; that is, all times <I>outside</I> the defined time range are
 * excluded.
 * <p>
 * Note when using <CODE>DailyCalendar</CODE>, it behaves on the same principals as, for example, {@link WeeklyCalendar
 * WeeklyCalendar}. <CODE>WeeklyCalendar</CODE> defines a set of days that are excluded <I>every week</I>. Likewise,
 * <CODE>DailyCalendar</CODE> defines a set of times that are excluded <I>every day</I>.
 *
 * @author Mike Funk, Aaron Craven
 */
public class DailyCalendar extends BaseCalendar {
    static final long serialVersionUID = -7561220099904944039L;

    private static final String invalidHourOfDay = "Invalid hour of day: ";
    private static final String invalidMinute = "Invalid minute: ";
    private static final String invalidSecond = "Invalid second: ";
    private static final String invalidMillis = "Invalid millis: ";
    private static final String invalidTimeRange = "Invalid time range: ";
    private static final String separator = " - ";
    private static final long oneMillis = 1;
    private static final String colon = ":";

    private int rangeStartingHourOfDay;
    private int rangeStartingMinute;
    private int rangeStartingSecond;
    private int rangeStartingMillis;
    private int rangeEndingHourOfDay;
    private int rangeEndingMinute;
    private int rangeEndingSecond;
    private int rangeEndingMillis;

    private boolean invertTimeRange = false;

    /**
     * Create a <CODE>DailyCalendar</CODE> with a time range defined by the specified strings and no
     * <CODE>baseCalendar</CODE>. <CODE>rangeStartingTime</CODE> and <CODE>rangeEndingTime</CODE> must be in the format
     * &quot;HH:MM[:SS[:mmm]]&quot; where:
     * <UL>
     * <LI>HH is the hour of the specified time. The hour should be specified using military (24-hour) time and must be
     * in the range 0 to 23.</LI>
     * <LI>MM is the minute of the specified time and must be in the range 0 to 59.</LI>
     * <LI>SS is the second of the specified time and must be in the range 0 to 59.</LI>
     * <LI>mmm is the millisecond of the specified time and must be in the range 0 to 999.</LI>
     * <LI>items enclosed in brackets ('[', ']') are optional.</LI>
     * <LI>The time range starting time must be before the time range ending time. Note this means that a time range may
     * not cross daily boundaries (10PM - 2AM)</LI>
     * </UL>
     *
     * <p>
     * <b>Note:</b> This <CODE>DailyCalendar</CODE> will use the <code>{@link TimeZone#getDefault()}</code> time zone
     * unless an explicit time zone is set via <code>{@link BaseCalendar#setTimeZone(TimeZone)}</code>
     * </p>
     *
     * @param rangeStartingTime a String representing the starting time for the time range
     * @param rangeEndingTime   a String representing the ending time for the the time range
     */
    public DailyCalendar(String rangeStartingTime, String rangeEndingTime) {
        super();
        setTimeRange(rangeStartingTime, rangeEndingTime);
    }

    /**
     * Create a <CODE>DailyCalendar</CODE> with a time range defined by the specified strings and the specified
     * <CODE>baseCalendar</CODE>. <CODE>rangeStartingTime</CODE> and <CODE>rangeEndingTime</CODE> must be in the format
     * &quot;HH:MM[:SS[:mmm]]&quot; where:
     * <UL>
     * <LI>HH is the hour of the specified time. The hour should be specified using military (24-hour) time and must be
     * in the range 0 to 23.</LI>
     * <LI>MM is the minute of the specified time and must be in the range 0 to 59.</LI>
     * <LI>SS is the second of the specified time and must be in the range 0 to 59.</LI>
     * <LI>mmm is the millisecond of the specified time and must be in the range 0 to 999.</LI>
     * <LI>items enclosed in brackets ('[', ']') are optional.</LI>
     * <LI>The time range starting time must be before the time range ending time. Note this means that a time range may
     * not cross daily boundaries (10PM - 2AM)</LI>
     * </UL>
     *
     * <p>
     * <b>Note:</b> This <CODE>DailyCalendar</CODE> will use the <code>{@link TimeZone#getDefault()}</code> time zone
     * unless an explicit time zone is set via <code>{@link BaseCalendar#setTimeZone(TimeZone)}</code>
     * </p>
     *
     * @param baseCalendar      the base calendar for this calendar instance &ndash; see {@link BaseCalendar} for more information on
     *                          base calendar functionality
     * @param rangeStartingTime a String representing the starting time for the time range
     * @param rangeEndingTime   a String representing the ending time for the time range
     */
    public DailyCalendar(ICalendar baseCalendar, String rangeStartingTime, String rangeEndingTime) {
        super(baseCalendar);
        setTimeRange(rangeStartingTime, rangeEndingTime);
    }

    public DailyCalendar(ICalendar baseCalendar, LocalTime rangeStartingTime, LocalTime rangeEndingTime) {
        super(baseCalendar);
        setTimeRange(rangeStartingTime, rangeEndingTime);
    }

    /**
     * Create a <CODE>DailyCalendar</CODE> with a time range defined by the specified values and no
     * <CODE>baseCalendar</CODE>. Values are subject to the following validations:
     * <UL>
     * <LI>Hours must be in the range 0-23 and are expressed using military (24-hour) time.</LI>
     * <LI>Minutes must be in the range 0-59</LI>
     * <LI>Seconds must be in the range 0-59</LI>
     * <LI>Milliseconds must be in the range 0-999</LI>
     * <LI>The time range starting time must be before the time range ending time. Note this means that a time range may
     * not cross daily boundaries (10PM - 2AM)</LI>
     * </UL>
     *
     * <p>
     * <b>Note:</b> This <CODE>DailyCalendar</CODE> will use the <code>{@link TimeZone#getDefault()}</code> time zone
     * unless an explicit time zone is set via <code>{@link BaseCalendar#setTimeZone(TimeZone)}</code>
     * </p>
     *
     * @param rangeStartingHourOfDay the hour of the start of the time range
     * @param rangeStartingMinute    the minute of the start of the time range
     * @param rangeStartingSecond    the second of the start of the time range
     * @param rangeStartingMillis    the millisecond of the start of the time range
     * @param rangeEndingHourOfDay   the hour of the end of the time range
     * @param rangeEndingMinute      the minute of the end of the time range
     * @param rangeEndingSecond      the second of the end of the time range
     * @param rangeEndingMillis      the millisecond of the start of the time range
     */
    public DailyCalendar(int rangeStartingHourOfDay, int rangeStartingMinute, int rangeStartingSecond,
                         int rangeStartingMillis, int rangeEndingHourOfDay, int rangeEndingMinute, int rangeEndingSecond,
                         int rangeEndingMillis) {
        super();
        setTimeRange(rangeStartingHourOfDay, rangeStartingMinute, rangeStartingSecond, rangeStartingMillis,
                rangeEndingHourOfDay, rangeEndingMinute, rangeEndingSecond, rangeEndingMillis);
    }

    /**
     * Create a <CODE>DailyCalendar</CODE> with a time range defined by the specified values and the specified
     * <CODE>baseCalendar</CODE>. Values are subject to the following validations:
     * <UL>
     * <LI>Hours must be in the range 0-23 and are expressed using military (24-hour) time.</LI>
     * <LI>Minutes must be in the range 0-59</LI>
     * <LI>Seconds must be in the range 0-59</LI>
     * <LI>Milliseconds must be in the range 0-999</LI>
     * <LI>The time range starting time must be before the time range ending time. Note this means that a time range may
     * not cross daily boundaries (10PM - 2AM)</LI>
     * </UL>
     *
     * <p>
     * <b>Note:</b> This <CODE>DailyCalendar</CODE> will use the <code>{@link TimeZone#getDefault()}</code> time zone
     * unless an explicit time zone is set via <code>{@link BaseCalendar#setTimeZone(TimeZone)}</code>
     * </p>
     *
     * @param baseCalendar           the base calendar for this calendar instance &ndash; see {@link BaseCalendar} for more information on
     *                               base calendar functionality
     * @param rangeStartingHourOfDay the hour of the start of the time range
     * @param rangeStartingMinute    the minute of the start of the time range
     * @param rangeStartingSecond    the second of the start of the time range
     * @param rangeStartingMillis    the millisecond of the start of the time range
     * @param rangeEndingHourOfDay   the hour of the end of the time range
     * @param rangeEndingMinute      the minute of the end of the time range
     * @param rangeEndingSecond      the second of the end of the time range
     * @param rangeEndingMillis      the millisecond of the start of the time range
     */
    public DailyCalendar(ICalendar baseCalendar, int rangeStartingHourOfDay, int rangeStartingMinute,
                         int rangeStartingSecond, int rangeStartingMillis, int rangeEndingHourOfDay, int rangeEndingMinute,
                         int rangeEndingSecond, int rangeEndingMillis) {
        super(baseCalendar);
        setTimeRange(rangeStartingHourOfDay, rangeStartingMinute, rangeStartingSecond, rangeStartingMillis,
                rangeEndingHourOfDay, rangeEndingMinute, rangeEndingSecond, rangeEndingMillis);
    }

    /**
     * Determines whether the given time (in milliseconds) is 'included' by the <CODE>BaseCalendar</CODE>
     *
     * @param timeInMillis the date/time to test
     * @return a boolean indicating whether the specified time is 'included' by the <CODE>BaseCalendar</CODE>
     */
    @Override
    public boolean isTimeIncluded(long timeInMillis) {
        if (timeInMillis <= 0)
            return false;

        if ((getBaseCalendar() != null) && (getBaseCalendar().isTimeIncluded(timeInMillis) == false)) {
            return false;
        }

        long startOfDayInMillis = getStartOfDayJavaCalendar(timeInMillis).getTime().getTime();
        long endOfDayInMillis = getEndOfDayJavaCalendar(timeInMillis).getTime().getTime();
        long timeRangeStartingTimeInMillis = getTimeRangeStartingTimeInMillis(timeInMillis);
        long timeRangeEndingTimeInMillis = getTimeRangeEndingTimeInMillis(timeInMillis);
        if (!invertTimeRange) {
            return ((timeInMillis > startOfDayInMillis && timeInMillis < timeRangeStartingTimeInMillis)
                    || (timeInMillis > timeRangeEndingTimeInMillis && timeInMillis < endOfDayInMillis));
        } else {
            return ((timeInMillis >= timeRangeStartingTimeInMillis) && (timeInMillis <= timeRangeEndingTimeInMillis));
        }
    }

    /**
     * Determines the next time included by the <CODE>DailyCalendar</CODE> after the specified time.
     *
     * @param timeInMillis the initial date/time after which to find an included time
     * @return the time in milliseconds representing the next time included after the specified time.
     */
    @Override
    public long getNextIncludedTime(long timeInMillis) {
        long nextIncludedTime = timeInMillis + oneMillis;

        while (!isTimeIncluded(nextIncludedTime)) {
            if (!invertTimeRange) {
                // If the time is in a range excluded by this calendar, we can
                // move to the end of the excluded time range and continue
                // testing from there. Otherwise, if nextIncludedTime is
                // excluded by the baseCalendar, ask it the next time it
                // includes and begin testing from there. Failing this, add one
                // millisecond and continue testing.
                if ((nextIncludedTime >= getTimeRangeStartingTimeInMillis(nextIncludedTime))
                        && (nextIncludedTime <= getTimeRangeEndingTimeInMillis(nextIncludedTime))) {

                    nextIncludedTime = getTimeRangeEndingTimeInMillis(nextIncludedTime) + oneMillis;
                } else if ((getBaseCalendar() != null) && (!getBaseCalendar().isTimeIncluded(nextIncludedTime))) {
                    nextIncludedTime = getBaseCalendar().getNextIncludedTime(nextIncludedTime);
                } else {
                    nextIncludedTime++;
                }
            } else {
                // If the time is in a range excluded by this calendar, we can
                // move to the end of the excluded time range and continue
                // testing from there. Otherwise, if nextIncludedTime is
                // excluded by the baseCalendar, ask it the next time it
                // includes and begin testing from there. Failing this, add one
                // millisecond and continue testing.
                if (nextIncludedTime < getTimeRangeStartingTimeInMillis(nextIncludedTime)) {
                    nextIncludedTime = getTimeRangeStartingTimeInMillis(nextIncludedTime);
                } else if (nextIncludedTime > getTimeRangeEndingTimeInMillis(nextIncludedTime)) {
                    // (move to start of next day)
                    nextIncludedTime = getEndOfDayJavaCalendar(nextIncludedTime).getTime().getTime();
                    nextIncludedTime += 1l;
                } else if ((getBaseCalendar() != null) && (!getBaseCalendar().isTimeIncluded(nextIncludedTime))) {
                    nextIncludedTime = getBaseCalendar().getNextIncludedTime(nextIncludedTime);
                } else {
                    nextIncludedTime++;
                }
            }
        }

        return nextIncludedTime;
    }

    /**
     * Returns the start time of the time range (in milliseconds) of the day specified in <CODE>timeInMillis</CODE>
     *
     * @param timeInMillis a time containing the desired date for the starting time of the time range.
     * @return a date/time (in milliseconds) representing the start time of the time range for the specified date.
     */
    public long getTimeRangeStartingTimeInMillis(long timeInMillis) {
        Calendar rangeStartingTime = createJavaCalendar(timeInMillis);
        rangeStartingTime.set(Calendar.HOUR_OF_DAY, rangeStartingHourOfDay);
        rangeStartingTime.set(Calendar.MINUTE, rangeStartingMinute);
        rangeStartingTime.set(Calendar.SECOND, rangeStartingSecond);
        rangeStartingTime.set(Calendar.MILLISECOND, rangeStartingMillis);
        return rangeStartingTime.getTime().getTime();
    }

    /**
     * Returns the end time of the time range (in milliseconds) of the day specified in <CODE>timeInMillis</CODE>
     *
     * @param timeInMillis a time containing the desired date for the ending time of the time range.
     * @return a date/time (in milliseconds) representing the end time of the time range for the specified date.
     */
    public long getTimeRangeEndingTimeInMillis(long timeInMillis) {
        Calendar rangeEndingTime = createJavaCalendar(timeInMillis);
        rangeEndingTime.set(Calendar.HOUR_OF_DAY, rangeEndingHourOfDay);
        rangeEndingTime.set(Calendar.MINUTE, rangeEndingMinute);
        rangeEndingTime.set(Calendar.SECOND, rangeEndingSecond);
        rangeEndingTime.set(Calendar.MILLISECOND, rangeEndingMillis);
        return rangeEndingTime.getTime().getTime();
    }

    /**
     * Indicates whether the time range represents an inverted time range (see class description).
     *
     * @return a boolean indicating whether the time range is inverted
     */
    public boolean getInvertTimeRange() {
        return invertTimeRange;
    }

    /**
     * Indicates whether the time range represents an inverted time range (see class description).
     *
     * @param flag the new value for the <CODE>invertTimeRange</CODE> flag.
     */
    public void setInvertTimeRange(boolean flag) {
        this.invertTimeRange = flag;
    }

    /**
     * Returns a string representing the properties of the <CODE>DailyCalendar</CODE>
     *
     * @return the properteis of the DailyCalendar in a String format
     */
    @Override
    public String toString() {
        NumberFormat numberFormatter = NumberFormat.getNumberInstance();
        numberFormatter.setMaximumFractionDigits(0);
        numberFormatter.setMinimumIntegerDigits(2);
        StringBuffer buffer = new StringBuffer();
        buffer.append("base calendar: [");
        if (getBaseCalendar() != null) {
            buffer.append(getBaseCalendar().toString());
        } else {
            buffer.append("null");
        }
        buffer.append("], time range: '");
        buffer.append(numberFormatter.format(rangeStartingHourOfDay));
        buffer.append(":");
        buffer.append(numberFormatter.format(rangeStartingMinute));
        buffer.append(":");
        buffer.append(numberFormatter.format(rangeStartingSecond));
        buffer.append(":");
        numberFormatter.setMinimumIntegerDigits(3);
        buffer.append(numberFormatter.format(rangeStartingMillis));
        numberFormatter.setMinimumIntegerDigits(2);
        buffer.append(" - ");
        buffer.append(numberFormatter.format(rangeEndingHourOfDay));
        buffer.append(":");
        buffer.append(numberFormatter.format(rangeEndingMinute));
        buffer.append(":");
        buffer.append(numberFormatter.format(rangeEndingSecond));
        buffer.append(":");
        numberFormatter.setMinimumIntegerDigits(3);
        buffer.append(numberFormatter.format(rangeEndingMillis));
        buffer.append("', inverted: " + invertTimeRange + "]");
        return buffer.toString();
    }

    /**
     * Helper method to split the given string by the given delimiter.
     */
    private String[] split(String string, String delim) {
        ArrayList<String> result = new ArrayList<String>();

        StringTokenizer stringTokenizer = new StringTokenizer(string, delim);
        while (stringTokenizer.hasMoreTokens()) {
            result.add(stringTokenizer.nextToken());
        }

        return (String[]) result.toArray(new String[result.size()]);
    }

    /**
     * Sets the time range for the <CODE>DailyCalendar</CODE> to the times represented in the specified Strings.
     *
     * @param rangeStartingTimeString a String representing the start time of the time range
     * @param rangeEndingTimeString   a String representing the end time of the excluded time range
     */
    public void setTimeRange(String rangeStartingTimeString, String rangeEndingTimeString) {
        String[] rangeStartingTime;
        int rStartingHourOfDay;
        int rStartingMinute;
        int rStartingSecond;
        int rStartingMillis;

        String[] rEndingTime;
        int rEndingHourOfDay;
        int rEndingMinute;
        int rEndingSecond;
        int rEndingMillis;

        rangeStartingTime = split(rangeStartingTimeString, colon);

        if ((rangeStartingTime.length < 2) || (rangeStartingTime.length > 4)) {
            throw new IllegalArgumentException("Invalid time string '" + rangeStartingTimeString + "'");
        }

        rStartingHourOfDay = Integer.parseInt(rangeStartingTime[0]);
        rStartingMinute = Integer.parseInt(rangeStartingTime[1]);
        if (rangeStartingTime.length > 2) {
            rStartingSecond = Integer.parseInt(rangeStartingTime[2]);
        } else {
            rStartingSecond = 0;
        }
        if (rangeStartingTime.length == 4) {
            rStartingMillis = Integer.parseInt(rangeStartingTime[3]);
        } else {
            rStartingMillis = 0;
        }

        rEndingTime = split(rangeEndingTimeString, colon);

        if ((rEndingTime.length < 2) || (rEndingTime.length > 4)) {
            throw new IllegalArgumentException("Invalid time string '" + rangeEndingTimeString + "'");
        }

        rEndingHourOfDay = Integer.parseInt(rEndingTime[0]);
        rEndingMinute = Integer.parseInt(rEndingTime[1]);
        if (rEndingTime.length > 2) {
            rEndingSecond = Integer.parseInt(rEndingTime[2]);
        } else {
            rEndingSecond = 0;
        }
        if (rEndingTime.length == 4) {
            rEndingMillis = Integer.parseInt(rEndingTime[3]);
        } else {
            rEndingMillis = 0;
        }

        setTimeRange(rStartingHourOfDay, rStartingMinute, rStartingSecond, rStartingMillis, rEndingHourOfDay,
                rEndingMinute, rEndingSecond, rEndingMillis);
    }

    /**
     * Sets the time range for the <CODE>DailyCalendar</CODE> to the times represented in the specified values.
     *
     * @param rangeStartingHourOfDay the hour of the start of the time range
     * @param rangeStartingMinute    the minute of the start of the time range
     * @param rangeStartingSecond    the second of the start of the time range
     * @param rangeStartingMillis    the millisecond of the start of the time range
     * @param rangeEndingHourOfDay   the hour of the end of the time range
     * @param rangeEndingMinute      the minute of the end of the time range
     * @param rangeEndingSecond      the second of the end of the time range
     * @param rangeEndingMillis      the millisecond of the start of the time range
     */
    public void setTimeRange(int rangeStartingHourOfDay, int rangeStartingMinute, int rangeStartingSecond,
                             int rangeStartingMillis, int rangeEndingHourOfDay, int rangeEndingMinute, int rangeEndingSecond,
                             int rangeEndingMillis) {
        validate(rangeStartingHourOfDay, rangeStartingMinute, rangeStartingSecond, rangeStartingMillis);

        validate(rangeEndingHourOfDay, rangeEndingMinute, rangeEndingSecond, rangeEndingMillis);

        Calendar startCal = createJavaCalendar();
        startCal.set(Calendar.HOUR_OF_DAY, rangeStartingHourOfDay);
        startCal.set(Calendar.MINUTE, rangeStartingMinute);
        startCal.set(Calendar.SECOND, rangeStartingSecond);
        startCal.set(Calendar.MILLISECOND, rangeStartingMillis);

        Calendar endCal = createJavaCalendar();
        endCal.set(Calendar.HOUR_OF_DAY, rangeEndingHourOfDay);
        endCal.set(Calendar.MINUTE, rangeEndingMinute);
        endCal.set(Calendar.SECOND, rangeEndingSecond);
        endCal.set(Calendar.MILLISECOND, rangeEndingMillis);

        if (!startCal.before(endCal)) {
            throw new IllegalArgumentException(invalidTimeRange + rangeStartingHourOfDay + ":" + rangeStartingMinute
                    + ":" + rangeStartingSecond + ":" + rangeStartingMillis + separator + rangeEndingHourOfDay + ":"
                    + rangeEndingMinute + ":" + rangeEndingSecond + ":" + rangeEndingMillis);
        }

        this.rangeStartingHourOfDay = rangeStartingHourOfDay;
        this.rangeStartingMinute = rangeStartingMinute;
        this.rangeStartingSecond = rangeStartingSecond;
        this.rangeStartingMillis = rangeStartingMillis;
        this.rangeEndingHourOfDay = rangeEndingHourOfDay;
        this.rangeEndingMinute = rangeEndingMinute;
        this.rangeEndingSecond = rangeEndingSecond;
        this.rangeEndingMillis = rangeEndingMillis;
    }

    /**
     * Sets the time range for the <CODE>DailyCalendar</CODE> to the times represented in the specified
     * <CODE>java.util.Calendar</CODE>s.
     *
     * @param rangeStartingCalendar a Calendar containing the start time for the <CODE>DailyCalendar</CODE>
     * @param rangeEndingCalendar   a Calendar containing the end time for the <CODE>DailyCalendar</CODE>
     */
    public void setTimeRange(LocalTime rangeStartingCalendar, LocalTime rangeEndingCalendar) {
        setTimeRange(rangeStartingCalendar.getHour(), rangeStartingCalendar.getMinute(),
                rangeStartingCalendar.getSecond(), 0, rangeEndingCalendar.getHour(), rangeEndingCalendar.getMinute(),
                rangeEndingCalendar.getSecond(), 0);
    }

    /**
     * Checks the specified values for validity as a set of time values.
     *
     * @param hourOfDay the hour of the time to check (in military (24-hour) time)
     * @param minute    the minute of the time to check
     * @param second    the second of the time to check
     * @param millis    the millisecond of the time to check
     */
    private void validate(int hourOfDay, int minute, int second, int millis) {
        if (hourOfDay < 0 || hourOfDay > 23) {
            throw new IllegalArgumentException(invalidHourOfDay + hourOfDay);
        }
        if (minute < 0 || minute > 59) {
            throw new IllegalArgumentException(invalidMinute + minute);
        }
        if (second < 0 || second > 59) {
            throw new IllegalArgumentException(invalidSecond + second);
        }
        if (millis < 0 || millis > 999) {
            throw new IllegalArgumentException(invalidMillis + millis);
        }
    }
}